iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 16

打造 BMI 表單介面進美化——TextInputLayout、ConstraintLayout 與 Drawable 圖文實戰

  • 分享至 

  • xImage
  •  

昨天使用 RadioButtonCheckBoxTextInputEditText 完成表單和 BMI 計算練習。今天要專注處理 BMI App 的使用者介面,學習如何透過 Android Studio Layout Editor 配置 TextInputLayout、設定 Constraint、加入輸入提示,並在 BMI 結果旁顯示體位圖片。參考資料:Android Developers-TextInputLayout

今天會完成:

  • 使用 Android Studio Layout Editor 設計 BMI 畫面。
  • 認識 TextInputLayoutTextInputEditText 的關係。
  • 設定姓名、身高與體重輸入欄位。
  • 使用 inputType 顯示適合的鍵盤。
  • 使用 ConstraintLayout 建立響應式版面。
  • 使用垂直 LinearLayout 整理表單。
  • 使用水平 LinearLayout 排列兩個按鈕。
  • 使用 TextView 顯示 BMI 計算結果。
  • 使用 Compound Drawable 顯示結果圖片。
  • 使用 drawablePadding 調整圖片與文字間距。
  • 將固定文字移至 strings.xml

參考資料:Android Developers-ConstraintLayoutAndroid Developers-TextView


一、 完成畫面

今天預計完成的 BMI 畫面包含:

  1. BMI data 標題。
  2. 姓名輸入欄位。
  3. 身高輸入欄位。
  4. 體重輸入欄位。
  5. Cancel 清除按鈕。
  6. BMI 計算按鈕。
  7. BMI 結果顯示區域。
  8. 根據 BMI 顯示的體位圖片。

以下是使用 Android Studio Layout Editor 完成的畫面。

Android Studio BMI 畫面設計完成

▲ 畫面中央是 App 預覽,左側為元件樹,右側則顯示目前選取元件的屬性。下方 Build Output 顯示 BUILD SUCCESSFUL,代表目前專案可以成功建置。參考資料:Android Developers-Layout Editor


二、使用 Layout Editor 加入輸入欄位

開啟以下檔案:

app
└── src
    └── main
        └── res
            └── layout
                └── activity_main.xml

切換到 DesignSplit 模式後,可以從左側 Palette 的 Text 分類,找到 TextInputLayout,再拖曳到畫面中。參考資料:Android Developers-Build a UI with Layout Editor

從 Palette 加入 TextInputLayout

▲ 參考畫面加入兩個 TextInputLayout,並將 layout_width 設為 match_parentlayout_height 設為 wrap_content。參考資料:Android Developers-TextInputLayout


三、match_parent、wrap_content 與 0dp

Android XML 中常見的尺寸設定如下。參考資料:Android Developers-Responsive layouts

設定值 意義
wrap_content 元件大小剛好容納內容
match_parent 盡量填滿父容器
0dp 在 ConstraintLayout 中代表 Match Constraints
120dp 使用固定的 dp 尺寸

參考畫面的 TextInputLayout 放在垂直 LinearLayout 中,因此可以設定:

android:layout_width="match_parent"
android:layout_height="wrap_content"

match_parent 讓輸入欄位寬度填滿父容器,wrap_content 則讓高度依內容決定。參考資料:Android Developers-ViewGroup.LayoutParams

如果元件直接放在 ConstraintLayout 中,官方建議透過左右 Constraint 搭配 0dp 填滿可用空間,而不是使用 match_parent。參考資料:Android Developers-ConstraintLayout sizing

android:layout_width="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"

四、TextInputLayout 與 TextInputEditText 的關係

TextInputLayout 是輸入欄位的外層容器,負責外框、浮動標籤、提示、錯誤訊息與輔助文字;真正接收使用者輸入的是放在裡面的 TextInputEditText。參考資料:Android Developers-TextInputLayout API

基本結構如下:

<com.google.android.material.textfield.TextInputLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="姓名">

    <com.google.android.material.textfield.TextInputEditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPersonName" />

</com.google.android.material.textfield.TextInputLayout>

TextInputEditText 應放在 TextInputLayout 中使用,這樣才能讓 Material 輸入框正確管理浮動標籤與無障礙資訊。參考資料:Android Developers-TextInputEditText


五、Hint 應該設定在哪裡?

Android 官方建議將 android:hint 設定在外層的 TextInputLayout,不要同時在 TextInputLayoutTextInputEditText 設定不同提示。參考資料:Android Developers-TextInputLayout hint

建議寫法:

<com.google.android.material.textfield.TextInputLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="@string/input_name">

    <com.google.android.material.textfield.TextInputEditText
        android:id="@+id/textInput_Name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPersonName" />

</com.google.android.material.textfield.TextInputLayout>

當輸入欄位尚未取得焦點而且沒有內容時,Hint 會顯示在欄位內;點擊輸入欄位或輸入內容後,Hint 會移動到外框上方,成為浮動標籤。參考資料:Material Design-Text fields


六、建立 BMI 表單的元件階層

今天的表單可以使用以下階層:

ConstraintLayout
├── TextView:BMI data
├── LinearLayout:表單輸入區
│   ├── TextView:Name
│   ├── TextInputLayout:姓名
│   │   └── TextInputEditText
│   ├── TextInputLayout:身高
│   │   └── TextInputEditText
│   └── TextInputLayout:體重
│       └── TextInputEditText
├── LinearLayout:按鈕區
│   ├── Button:Cancel
│   └── Button:BMI
└── TextView:BMI 結果

使用垂直 LinearLayout 可以依序排列姓名、身高和體重欄位;兩個按鈕則使用水平 LinearLayout 排列。參考資料:Android Developers-LinearLayout


七、activity_main.xml 完整程式

以下 XML 依照參考畫面重新整理,保留畫面中的元件 ID,並補上完整 Constraint、Material 輸入框、數字鍵盤、錯誤空間及結果顯示區域。參考資料:Android Developers-XML layouts

<?xml version="1.0" encoding="utf-8"?>

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingStart="24dp"
    android:paddingTop="24dp"
    android:paddingEnd="24dp"
    android:paddingBottom="24dp"
    tools:context=".MainActivity">

    <!--
        BMI App 的主標題。
        使用 0dp 搭配左右 Constraint,
        讓寬度依照父容器的可用空間決定。
    -->
    <TextView
        android:id="@+id/textView_BMItitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="@string/bmi_title"
        android:textColor="@color/bmi_title_color"
        android:textSize="28sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!--
        BMI 輸入表單。
        使用垂直 LinearLayout,
        依序排列姓名、身高及體重輸入欄位。
    -->
    <LinearLayout
        android:id="@+id/linearLayout_BMIForm"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:orientation="vertical"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView_BMItitle">

        <!-- 姓名欄位上方的說明文字。 -->
        <TextView
            android:id="@+id/textView_Name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="6dp"
            android:text="@string/name_label"
            android:textColor="@color/bmi_label_color"
            android:textSize="18sp"
            android:textStyle="bold" />

        <!--
            姓名輸入框的外層 TextInputLayout。
            Hint 設定在 TextInputLayout,
            輸入時會變成浮動標籤。
        -->
        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textInputLayout_Name"
            style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/input_name"
            app:boxStrokeWidth="1dp"
            app:boxStrokeWidthFocused="2dp">

            <!-- 真正接收姓名文字的輸入元件。 -->
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/textInput_Name"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:imeOptions="actionNext"
                android:inputType="textPersonName"
                android:maxLines="1"
                android:minHeight="56dp" />

        </com.google.android.material.textfield.TextInputLayout>

        <!--
            身高輸入框。
            數值可能包含小數,因此使用 numberDecimal。
            suffixText 會在欄位尾端顯示 cm。
        -->
        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textInputLayout_Height"
            style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:hint="@string/height_hint"
            app:helperText="@string/height_helper"
            app:suffixText="@string/height_unit">

            <!-- 真正接收身高數字的輸入元件。 -->
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/textInput_Height"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:digits="0123456789."
                android:imeOptions="actionNext"
                android:inputType="numberDecimal"
                android:maxLines="1"
                android:minHeight="56dp" />

        </com.google.android.material.textfield.TextInputLayout>

        <!--
            體重輸入框。
            suffixText 顯示 kg,
            helperText 說明輸入單位。
        -->
        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textInputLayout_Weight"
            style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:hint="@string/weight_hint"
            app:helperText="@string/weight_helper"
            app:suffixText="@string/weight_unit">

            <!-- 真正接收體重數字的輸入元件。 -->
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/textInput_Weight"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:digits="0123456789."
                android:imeOptions="actionDone"
                android:inputType="numberDecimal"
                android:maxLines="1"
                android:minHeight="56dp" />

        </com.google.android.material.textfield.TextInputLayout>

    </LinearLayout>

    <!--
        兩個功能按鈕的水平容器。
        使用 layout_weight 平均分配寬度。
    -->
    <LinearLayout
        android:id="@+id/linearLayout_Buttons"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:gravity="center"
        android:orientation="horizontal"
        android:weightSum="2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/linearLayout_BMIForm">

        <!-- 清除所有輸入資料。 -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/button_Cancel"
            style="@style/Widget.Material3.Button.OutlinedButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="12dp"
            android:layout_weight="1"
            android:minHeight="48dp"
            android:text="@string/cancel" />

        <!-- 執行 BMI 計算。 -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/button_BMI"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:layout_weight="1"
            android:minHeight="48dp"
            android:text="@string/calculate_bmi" />

    </LinearLayout>

    <!--
        顯示姓名、BMI 數值與體位結果。
        drawableStart 可以在文字起始位置顯示圖片;
        drawablePadding 則設定圖片與文字之間的距離。
    -->
    <TextView
        android:id="@+id/textView_BMI"
        android:layout_width="0dp"
        android:layout_height="160dp"
        android:layout_marginTop="20dp"
        android:background="@drawable/bg_bmi_result"
        android:drawablePadding="10dp"
        android:gravity="center_vertical"
        android:padding="16dp"
        android:text="@string/bmi_result_placeholder"
        android:textColor="@color/bmi_result_text_color"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/linearLayout_Buttons"
        tools:drawableStart="@drawable/fat_4"
        tools:text="Name: Alex&#10;BMI: 22.86&#10;BMI 結果:正常" />

</androidx.constraintlayout.widget.ConstraintLayout>

八、為什麼輸入欄位使用 numberDecimal?

身高和體重都可能包含小數,例如:

身高:168.5 公分
體重:79.2 公斤

因此 XML 使用:

android:inputType="numberDecimal"

這會要求輸入法顯示適合輸入小數的數字鍵盤。inputType 主要用來提示輸入法應提供哪種鍵盤與輸入行為,但程式仍要驗證輸入內容。參考資料:Android Developers-Specify input method type

另外加入:

android:digits="0123456789."

可以限制欄位接受的字元,但它仍不能保證輸入一定是合法小數,例如使用者可能輸入兩個小數點,所以 Kotlin 端仍應使用 toDoubleOrNull() 檢查。參考資料:Android Developers-TextView digits


九、使用 suffixText 顯示單位

身高與體重欄位可以在 TextInputLayout 設定單位:

app:suffixText="cm"

以及:

app:suffixText="kg"

suffixText 只是顯示在輸入框尾端的輔助文字,不會成為 TextInputEditText.text 的內容,因此 Kotlin 讀取到的仍是純數值,例如 16879.2。參考資料:Android Developers-TextInputLayout suffix text


十、使用 helperText 提醒輸入格式

參考畫面在身高欄位下方顯示 cm,體重欄位下方顯示 kg。這類提示可以使用 helperText。參考資料:Android Developers-TextInputLayout helper text

app:helperText="請輸入公分,例如 168"
app:helperText="請輸入公斤,例如 79.2"

helperText 適合顯示正常狀態下的格式說明;如果輸入錯誤,則可以使用 error 顯示錯誤訊息。參考資料:Android Developers-TextInputLayout setError


十一、strings.xml 完整程式

固定文字不應直接寫死在 Layout XML 或 Kotlin 中,建議統一放入 res/values/strings.xml,方便日後修改與多語系處理。參考資料:Android Developers-String resources

<?xml version="1.0" encoding="utf-8"?>

<resources>

    <!-- App 名稱。 -->
    <string name="app_name">BMI Calculator</string>

    <!-- 畫面主標題。 -->
    <string name="bmi_title">BMI data</string>

    <!-- 姓名欄位。 -->
    <string name="name_label">Name:</string>
    <string name="input_name">Input name</string>

    <!-- 身高欄位。 -->
    <string name="height_hint">Height</string>
    <string name="height_unit">cm</string>
    <string name="height_helper">請輸入公分,例如 168</string>

    <!-- 體重欄位。 -->
    <string name="weight_hint">Weight</string>
    <string name="weight_unit">kg</string>
    <string name="weight_helper">請輸入公斤,例如 79.2</string>

    <!-- 功能按鈕。 -->
    <string name="cancel">Cancel</string>
    <string name="calculate_bmi">BMI</string>

    <!-- BMI 結果。 -->
    <string name="bmi_result_placeholder">請輸入資料並按下 BMI</string>

    <!-- 輸入錯誤訊息。 -->
    <string name="error_name_required">請輸入姓名</string>
    <string name="error_height_required">請輸入身高</string>
    <string name="error_weight_required">請輸入體重</string>
    <string name="error_height_invalid">身高格式錯誤</string>
    <string name="error_weight_invalid">體重格式錯誤</string>

    <!-- BMI 體位分類。 -->
    <string name="bmi_underweight">過輕</string>
    <string name="bmi_normal">正常</string>
    <string name="bmi_overweight">過重</string>
    <string name="bmi_obese">肥胖</string>

</resources>

十二、colors.xml 完整程式

以下顏色對應參考畫面中的藍色標題、綠色輸入提示、紫色按鈕與淺色結果背景。參考資料:Android Developers-Color resources

<?xml version="1.0" encoding="utf-8"?>

<resources>

    <!-- BMI 主標題顏色。 -->
    <color name="bmi_title_color">#3949AB</color>

    <!-- 表單標籤顏色。 -->
    <color name="bmi_label_color">#039BE5</color>

    <!-- 結果文字顏色。 -->
    <color name="bmi_result_text_color">#263238</color>

    <!-- 結果區域背景顏色。 -->
    <color name="bmi_result_background">#F7F5FC</color>

    <!-- 結果區域邊框顏色。 -->
    <color name="bmi_result_stroke">#D1C4E9</color>

</resources>

十三、建立 BMI 結果背景

在以下位置建立:

app/src/main/res/drawable/bg_bmi_result.xml

完整內容如下。參考資料:Android Developers-Shape drawable

<?xml version="1.0" encoding="utf-8"?>

<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <!-- 結果區域的淺色背景。 -->
    <solid
        android:color="@color/bmi_result_background" />

    <!-- 圓角大小。 -->
    <corners
        android:radius="16dp" />

    <!-- 淡紫色外框。 -->
    <stroke
        android:width="1dp"
        android:color="@color/bmi_result_stroke" />

    <!-- Shape 內部留白。 -->
    <padding
        android:left="12dp"
        android:top="12dp"
        android:right="12dp"
        android:bottom="12dp" />

</shape>

十四、什麼是 Compound Drawable?

TextView 可以在文字的左、上、右、下方加入 Drawable,這些圖片稱為 Compound Drawables。參考資料:Android Developers-TextView compound drawables

XML 可使用:

android:drawableStart="@drawable/fat_4"

drawableStart 代表文字閱讀方向的起始位置。在繁體中文與英文的由左至右介面中,它通常顯示在文字左側;相較於 drawableLeft,使用 drawableStart 更能支援不同文字方向。參考資料:Android Developers-Support different languages

其他方向還包括:

android:drawableTop="@drawable/fat_4"
android:drawableEnd="@drawable/fat_4"
android:drawableBottom="@drawable/fat_4"

參考資料:Android Developers-TextView XML attributes


十五、drawablePadding 的作用

參考畫面右側 Attributes 搜尋 draw,並將 drawablePadding 設為 10dp

設定 TextView 的 drawablePadding

drawablePadding 用來設定圖片和文字之間的距離。若沒有設定,圖片與文字可能靠得太近;設定 10dp 後,畫面會有較自然的留白。參考資料:Android Developers-android:drawablePadding

android:drawablePadding="10dp"

drawablePadding 只有在 TextView 已設定 Compound Drawable 時才會產生明顯效果。它不是設定圖片與 TextView 外框的間距;外框間距應使用 padding。參考資料:Android Developers-TextView


十六、tools:drawableStart 與 android:drawableStart 的差別

如果希望圖片只出現在 Android Studio 預覽中,可以使用:

tools:drawableStart="@drawable/fat_4"

如果希望 App 實際執行時也顯示圖片,則使用:

android:drawableStart="@drawable/fat_4"

tools: 命名空間的屬性只提供設計與預覽工具使用,不會被打包成實際執行效果。這很適合先預覽 BMI 結果區域,實際圖片再交由 Kotlin 依照 BMI 動態決定。參考資料:Android Developers-Tools attributes reference


十七、MainActivity 完整程式

以下 Kotlin 程式會讀取三個輸入欄位、驗證資料、計算 BMI,再更新結果文字與圖片。新增或修改的部分已加入完整註解。參考資料:Android Developers-Handle user input

package com.example.widget_8

import android.graphics.drawable.Drawable
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout

class MainActivity : AppCompatActivity() {

    // 姓名輸入框外層容器,用於顯示錯誤訊息。
    private lateinit var textInputLayoutName:
        TextInputLayout

    // 身高輸入框外層容器,用於顯示錯誤訊息。
    private lateinit var textInputLayoutHeight:
        TextInputLayout

    // 體重輸入框外層容器,用於顯示錯誤訊息。
    private lateinit var textInputLayoutWeight:
        TextInputLayout

    // 真正接收姓名輸入內容的元件。
    private lateinit var textInputName:
        TextInputEditText

    // 真正接收身高輸入內容的元件。
    private lateinit var textInputHeight:
        TextInputEditText

    // 真正接收體重輸入內容的元件。
    private lateinit var textInputWeight:
        TextInputEditText

    // 清除輸入內容的按鈕。
    private lateinit var buttonCancel:
        Button

    // 執行 BMI 計算的按鈕。
    private lateinit var buttonBMI:
        Button

    // 顯示姓名、BMI 數值與體位結果。
    private lateinit var textViewBMI:
        TextView

    override fun onCreate(
        savedInstanceState: Bundle?
    ) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge 顯示模式。
        enableEdgeToEdge()

        // 載入 activity_main.xml。
        setContentView(R.layout.activity_main)

        // 處理狀態列與導覽列的範圍,
        // 避免內容被系統列遮住。
        ViewCompat.setOnApplyWindowInsetsListener(
            findViewById(R.id.main)
        ) { view, insets ->

            val systemBars =
                insets.getInsets(
                    WindowInsetsCompat.Type.systemBars()
                )

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 取得 XML 中的所有畫面元件。
        initViews()

        // 設定按鈕點擊事件。
        setupClickListeners()
    }

    /**
     * 使用 findViewById() 取得 XML 元件。
     */
    private fun initViews() {

        textInputLayoutName =
            findViewById(
                R.id.textInputLayout_Name
            )

        textInputLayoutHeight =
            findViewById(
                R.id.textInputLayout_Height
            )

        textInputLayoutWeight =
            findViewById(
                R.id.textInputLayout_Weight
            )

        textInputName =
            findViewById(
                R.id.textInput_Name
            )

        textInputHeight =
            findViewById(
                R.id.textInput_Height
            )

        textInputWeight =
            findViewById(
                R.id.textInput_Weight
            )

        buttonCancel =
            findViewById(
                R.id.button_Cancel
            )

        buttonBMI =
            findViewById(
                R.id.button_BMI
            )

        textViewBMI =
            findViewById(
                R.id.textView_BMI
            )
    }

    /**
     * 設定清除與 BMI 計算按鈕。
     */
    private fun setupClickListeners() {

        buttonCancel.setOnClickListener {

            // 清空三個輸入欄位。
            textInputName.text?.clear()
            textInputHeight.text?.clear()
            textInputWeight.text?.clear()

            // 清除 TextInputLayout 錯誤訊息。
            clearInputErrors()

            // 清空 BMI 結果。
            textViewBMI.text = ""

            // 清除 TextView 左側的結果圖片。
            textViewBMI.setCompoundDrawables(
                null,
                null,
                null,
                null
            )

            // 將輸入焦點移回姓名欄位。
            textInputName.requestFocus()
        }

        buttonBMI.setOnClickListener {

            // 每次計算前先清除上一輪的錯誤。
            clearInputErrors()

            // 讀取並清理使用者輸入。
            val name =
                textInputName.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            val heightText =
                textInputHeight.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            val weightText =
                textInputWeight.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            // 檢查姓名。
            if (name.isEmpty()) {

                textInputLayoutName.error =
                    getString(
                        R.string.error_name_required
                    )

                textInputName.requestFocus()

                return@setOnClickListener
            }

            // 將身高安全轉換成 Double。
            val height =
                heightText.toDoubleOrNull()

            // 檢查身高是否有效。
            if (height == null || height <= 0) {

                textInputLayoutHeight.error =
                    getString(
                        R.string.error_height_invalid
                    )

                textInputHeight.requestFocus()

                return@setOnClickListener
            }

            // 將體重安全轉換成 Double。
            val weight =
                weightText.toDoubleOrNull()

            // 檢查體重是否有效。
            if (weight == null || weight <= 0) {

                textInputLayoutWeight.error =
                    getString(
                        R.string.error_weight_invalid
                    )

                textInputWeight.requestFocus()

                return@setOnClickListener
            }

            // 將公分轉換成公尺。
            val heightInMeters =
                height / 100.0

            // BMI=體重÷身高平方。
            val bmi =
                weight /
                    (
                        heightInMeters *
                        heightInMeters
                    )

            // 判斷 BMI 體位分類。
            val category =
                getBMICategory(bmi)

            // 顯示完整計算結果。
            textViewBMI.text =
                """
                Name:$name
                Height:$height cm
                Weight:$weight kg
                BMI:${String.format("%.2f", bmi)}
                BMI 結果:$category
                """.trimIndent()

            // 更新結果圖片。
            updateResultDrawable(bmi)
        }
    }

    /**
     * 清除三個 TextInputLayout 的錯誤訊息。
     */
    private fun clearInputErrors() {

        textInputLayoutName.error = null
        textInputLayoutHeight.error = null
        textInputLayoutWeight.error = null
    }

    /**
     * 依照台灣成人 BMI 標準判斷體位。
     */
    private fun getBMICategory(
        bmi: Double
    ): String {

        return when {
            bmi < 18.5 ->
                getString(
                    R.string.bmi_underweight
                )

            bmi < 24.0 ->
                getString(
                    R.string.bmi_normal
                )

            bmi < 27.0 ->
                getString(
                    R.string.bmi_overweight
                )

            else ->
                getString(
                    R.string.bmi_obese
                )
        }
    }

    /**
     * 根據 BMI 選擇對應圖片,
     * 再顯示到結果 TextView 的起始位置。
     */
    private fun updateResultDrawable(
        bmi: Double
    ) {

        // 根據 BMI 分類決定 Drawable 資源。
        val imageResource =
            when {
                bmi < 18.5 ->
                    R.drawable.fat_3

                bmi < 24.0 ->
                    R.drawable.fat_4

                bmi < 27.0 ->
                    R.drawable.fat_2

                else ->
                    R.drawable.fat_1
            }

        // 安全取得 Drawable。
        val drawable: Drawable? =
            ContextCompat.getDrawable(
                this,
                imageResource
            )

        // 將 110dp 轉換成實際像素。
        val imageSize =
            (
                110 *
                    resources
                        .displayMetrics
                        .density
            ).toInt()

        // 設定圖片顯示範圍。
        drawable?.setBounds(
            0,
            0,
            imageSize,
            imageSize
        )

        // 將圖片放在文字起始位置。
        // 使用 Relative 版本可以支援不同文字方向。
        textViewBMI
            .setCompoundDrawablesRelative(
                drawable,
                null,
                null,
                null
            )

        // 動態設定圖片和文字之間的 10dp 間距。
        textViewBMI.compoundDrawablePadding =
            (
                10 *
                    resources
                        .displayMetrics
                        .density
            ).toInt()
    }
}

十八、在 TextInputLayout 顯示錯誤

使用 Toast 可以顯示短暫訊息,但表單錯誤更適合直接顯示在輸入欄位旁。TextInputLayout.error 會在欄位下方顯示錯誤內容。參考資料:Android Developers-TextInputLayout error

textInputLayoutHeight.error =
    "身高格式錯誤"

清除錯誤:

textInputLayoutHeight.error = null

這樣使用者可以立即知道是哪個欄位有問題,不必記住短暫消失的 Toast 訊息。參考資料:Material Design-Text field error messages


十九、BMI 計算範例

假設輸入:

Name:Alex
Height:168 cm
Weight:79.2 kg

身高先換算成公尺:

168 ÷ 100 = 1.68 公尺

BMI 計算:

BMI = 79.2 ÷(1.68 × 1.68)
    = 79.2 ÷ 2.8224
    ≈ 28.06

依照台灣成人體位標準,BMI 大於等於 27 屬於肥胖範圍。BMI 只能作為成人健康體位的初步參考,不能單獨作為疾病診斷。參考資料:衛生福利部國民健康署-成人健康體位標準


二十、常見版面問題

問題一:TextInputLayout 沒有填滿寬度

如果它放在垂直 LinearLayout 中,可以設定:

android:layout_width="match_parent"

如果直接放在 ConstraintLayout 中,則建議設定:

android:layout_width="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"

參考資料:Android Developers-ConstraintLayout


問題二:元件跑到左上角

直接放在 ConstraintLayout 中的元件必須設定水平與垂直 Constraint。如果只有在 Layout Editor 裡拖曳位置,卻沒有建立 Constraint,執行時元件可能回到左上角。參考資料:Android Developers-Position a view


問題三:Hint 不會浮動

確認結構是:

TextInputLayout
└── TextInputEditText

並且將 Hint 設定在 TextInputLayout。如果單獨使用一般 EditText,就不會得到完整的 Material 浮動標籤行為。參考資料:Android Developers-TextInputLayout


問題四:BMI 圖片沒有出現

請依序檢查:

  1. 圖片是否放在 res/drawable
  2. 檔名是否只包含小寫字母、數字與底線。
  3. R.drawable.fat_1 是否能正確解析。
  4. Drawable 是否已設定 Bounds。
  5. setCompoundDrawablesRelative() 是否已被呼叫。
  6. TextView 是否具有足夠的高度。

參考資料:Android Developers-Drawable resources


問題五:圖片和文字黏在一起

XML 設定:

android:drawablePadding="10dp"

或在 Kotlin 中設定:

textViewBMI.compoundDrawablePadding =
    (
        10 *
            resources.displayMetrics.density
    ).toInt()

Kotlin API 接收的是像素,所以必須先將 dp 轉成像素。參考資料:Android Developers-setCompoundDrawablePadding


二十一、今天學到的重點

今天從 Android Studio Layout Editor 開始,完成 BMI App 的表單與結果版面:

  1. 使用 ConstraintLayout 建立主要畫面。
  2. 使用 Constraint 控制元件相對位置。
  3. 在 ConstraintLayout 中使用 0dp 表示 Match Constraints。
  4. 使用垂直 LinearLayout 整理輸入欄位。
  5. 使用水平 LinearLayout 排列功能按鈕。
  6. 使用 TextInputLayout 建立 Material 輸入框。
  7. 使用 TextInputEditText 接收實際輸入。
  8. 將 Hint 設定在 TextInputLayout
  9. 使用 inputType="numberDecimal" 輸入小數。
  10. 使用 suffixText 顯示 cmkg
  11. 使用 helperText 提醒輸入格式。
  12. 使用 TextInputLayout.error 顯示欄位錯誤。
  13. 使用 Compound Drawable 在結果文字旁顯示圖片。
  14. 使用 drawablePadding 調整圖片與文字間距。
  15. 使用 tools: 屬性建立只供預覽的內容。
  16. 將固定文字移至 strings.xml
  17. 使用 Shape Drawable 建立結果區域背景。
  18. 完成輸入、驗證、計算與畫面更新流程。

參考資料:Android Developers-Build a responsive UIAndroid Developers-TextInputLayout


https://ithelp.ithome.com.tw/upload/images/20260816/20112100otDl2uQoSt.png

結語

今天不只是把元件放上畫面,而是進一步理解每個元件在版面中的責任:ConstraintLayout 控制主要位置、LinearLayout 管理連續排列、TextInputLayout 負責輸入框外觀與錯誤訊息、TextInputEditText 負責接收資料,而 TextView 則同時顯示 BMI 文字和體位圖片。參考資料:Android Developers-Views layouts

完成 Day 16 後,BMI App 已具備較完整的表單結構、輸入驗證與結果顯示。下一篇可以繼續學習 Spinner 下拉選單與 Switch 開關元件,讓使用者選擇運動頻率、單位或其他健康資料。參考資料:Android Developers-UI components


上一篇
Android 表單元件實戰——RadioButton、CheckBox 與 BMI 計算器
下一篇
Android 圖片選擇器實作:GridView 圖片清單、點擊事件與 ImageView 動態切換
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言